Skip to content

---------------------JVM---------------------

工具

查看字节码的软件

  • jclasslib插件

  • javap

    • shell
      jar –xvf xxxx.jar
    • shell
      javap -v xxxx.class > xxxx.txt
  • Arthas

    • shell
      java -jar arthas-boot.jar
    • shell
      jad --source-only com.demo.package.Main.clss

常用的Java内存调试工具

jmap、jstack、jconsole、jhat
jstack 可以看当前栈的情况,jmap 查看内存,jhat 进行 dump 堆的信息 mat

基本概念

Java程序执行过程

image-20240911160342145

JVM的运行流程

image-20240518154206180

JVM的组成

  • 类加载子系统(Class Loader):核心组件类加载器,负责将字节码文件中的内容加载到内存中。
  • 运行时数据区(Runtime Data Area):JVM管理的内存,创建出来的对象、类的信息等等内容都会放在这块区域中。
  • 执行引擎(Execution Engine):包含了即时编译器、解释器、垃圾回收器,执行引擎使用解释器将字节码指令解释成机器码,使用即时编译器优化性能,使用垃圾回收器回收不再使用的对象。
  • 本地接口(Native Interface):调用本地使用C/C++编译好的方法,本地方法在Java中声明时,都会带上native关键字。
1359e67d-c872-4a59-87bf-224d31a36b1c

JVM的内存结构

JVM 分为堆区和栈区,还有方法区,初始化的对象放在堆里面,引用放在栈里面, class 类信息常量池(static 常量和 static 变量)等放在方法区

  • (Heap):虚拟机中最大的一块内存区域,几乎所有的对象实例都在这里分配内存,初始化的对象、成员变量 (非 static 变量),所有的对象实例和数组都要在堆上分配。

    • 年轻代(Young Generation)
      • Eden区
      • Survivor区(From和To)
    • 老年代(Old Generation)
    • 字符串常量池
  • 方法区(Method Area) / 元空间(Meta Space):主要是存储类信息,常量池(static 常量和 static 变量),编译后的代码(字节码)等数据。方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法如构造器,接口代码也在此定义。简单说,所有定义的方法的信息都保存在该区域,此区属于共享区间。静态变量+常量+类信息+运行时常量池存在方法区中,实例变量存在堆内存中

    • 运行时常量池(Runtime Constant Pool): 方法区内的一部分,存放了编译期生成的各种字面量和符号引用。
  • 程序计数器(Program Counter Register):每个线程都有一个程序计数器。是一块较小的内存空间,记录当前线程执行的行号,本质是一个指针,指向方法区中的方法字节码(下一个将要执行的指令代码),由执行引擎读取下一条指令。

  • 虚拟机栈(VM Stack):由栈帧组成,调用一个方法就会压入一帧,栈帧上面存储局部变量表操作数栈方法出口等方法从调用直至执行完成的过程中的所有数据,局部变量表存放的是 8 大基础类型加上一个应用类型,所以还是一个指向地址的指针。栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命周期是跟随线程的生命周期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over,生命周期和线程一致,是线程私有的。

  • 本地方法栈(Native Method Stack):与虚拟机栈功能类似,为 Native 方法服务。它的具体做法是 Native Method Stack中登记native方法,在Execution Engine 执行时加载native libraries。

JVM的版本变化(JDK 7~8)

image-20240518160048142

1.7中的永久代(存储的是类信息、静态变量、常量、编译后的代码)在堆中

1.8移除了永久代,把数据存储到了本地内存的元空间中,防止内存溢出

在JDK7中,堆内存通常被分为Nursery内存(young generation)、长时内存(old generation)和永久内存(Permanent Generation for VM Matedata)。

在JDK8中,存放元数据中的永久内存从堆内存中移到了本地内存(native memory)中,因此不再占用堆内存。这一改变有助于避免由于永久内存不足而导致的内存溢出错误。同时,JDK8中方法区的实现也发生了变化,它现在存在于元空间(Metaspace)中,且元空间与堆内存不再连续,而是存在于本地内存中。

Java内存模型(JMM)

Java内存模型(JMM,Java Memory Model)主要关注的是线程之间如何通信,以及如何确保线程之间共享数据的一致性。

JMMJVM 规范的一部分,它定义了多线程对共享变量的访问规则、可见性、有序性和原子性

JMM 的设计目的是为了保证在多线程环境下程序执行的一致性和可预测性

JMM 把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存。

JMM的特性

为了保证下述特性,Java内存模型采用了一些机制,如happens-before原则,它是一组必须遵守的规则,确保了多线程环境下变量更新的可见性。当一个线程的某个操作发生在另一个线程的操作之前时,就意味着前者对后者有发生的影响。

可见性

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改时记录锁标识(如synchronized关键字)来保证可见性的。当一个变量被声明为volatile时,也会提供一种较弱形式的可见性保证。

原子性

原子性是指一个操作要么全部完成,要么全部不完成,不会中断。Java内存模型中基本数据类型的赋值操作具有原子性。对于复合操作(如i++),如果不特别标记(如使用synchronized),则不具备原子性。

有序性

有序性是指程序执行的顺序按照代码的先后顺序来进行,但由于指令重排序的存在,实际执行可能会与代码顺序有所不同。Java内存模型通过锁和volatile等关键字来强制执行上下文的有序性。

指令重排序

指令重排序是为了优化程序执行效率,编译器和处理器可能会改变语句的执行顺序,只要最终结果与按照原顺序执行的结果相同。虽然大多数情况下这种重排序不会影响单线程程序的正确性,但对于多线程程序来说,就可能会影响程序的正确性。

JMM的内存溢出情况

  1. 栈内存溢出:如果请求栈的深度过大而超出了栈所能承受的范围,就会抛出StackOverflowError错误。这通常发生在有大量递归调用的情况下。
  2. 堆内存溢出:当堆内存不足以存放更多的对象时,会发生堆内存溢出。错误信息通常显示为:java.lang.OutOfMemoryError: Java heap space。
  3. 方法区/元空间内存溢出:如果加载的类过多或者常量池中保存的常量过多、动态代理导致反复生成的类型过多,都有可能导致方法区/元空间的内存溢出。

虚拟机栈

含义:每个线程运行时所需要的内存。每个虚拟机(Stack)由多个栈帧(Stack Frame)组成,对应着每次方法调用时所占用的内存。每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。

栈帧中主要保存3 类数据:

本地变量(Local Variables):输入参数和输出参数以及方法内的变量

栈操作(Operand Stack):记录出栈、入栈的操作; 栈帧数据(Frame Data):包括类文件、方法等。

栈中的数据都是以栈帧(Stack Frame)的格式存在 遵循“先进后出”/“后进先出”原则

栈内存溢出栈帧过多(递归调用),或栈帧过大

类加载器

含义:通过类的权限定名获取该类的二进制字节流的代码块叫做类加载器。 负责加载class文件,class文件在文件开头有特定的文件标示,并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。

作用:在类加载过程中,获取并加载字节码(.class文件),放到内存中,转换成二进制文件(byte[]),并调用虚拟机底层方法将二进制文件转换成方法区和堆中的数据。

类型

  • 启动类加载器(Bootstrap Class Loader):加载核心jar包(JAVA_HOME/jre/lib目录下的库)
  • 扩展类加载器(Extensions Class Loader):加载通用的扩展jar包(JAVA_HOME/jre/lib/ext目录中的类)
  • 应用程序类加载器(Application Class Loader):加载项目中的类、maven中的引用的jar包等(加载classPath下的类)

类加载的过程

  1. 加载、验证、准备、解析、初始化。然后是使用和卸载
  2. 通过全限定名来加载生成 class 对象到内存中,然后进行验证这个 class 文件,包括文 件格式校验、元数据验证,字节码校验等。准备是对这个对象分配内存。
  3. 解析是将符 号引用转化为直接引用(指针引用),初始化就是开始执行构造器的代码

类的生命周期,类装载的执行过程

  1. 加载:类加载器根据类的全限定名获取类的二进制数据流,解析二进制数据流为方法区内的Java类模型,最后创建java.lang.Class类的实例

  2. 连接:

    • 验证:验证内容是否满足《Java虚拟机规范》(文件格式验证、元信息验证、验证程序执行指令的语义、符号引用验证)
    • 准备:给静态变量赋初值
    • 解析:将常量池中的符号引用替换成指向内存的直接引用。
  3. 初始化:执行字节码文件中 clinit 方法的字节码指令,包含了静态代码块中的代码,并为静态变量赋值

  4. 使用:调用静态类成员信息、使用new关键字为其创建对象实例、执行用户的程序代码

  5. 卸载(详见垃圾回收):代码执行完毕后,JVM销毁Class对象

双亲委派机制

双亲委派机制:当一个类加载器接收到加载类的任务时,会向上查找是否加载过,如果加载过,就直接返回,如果没有加载,就向下委派子类加载。

作用:

  • 避免核心类库被覆写,确保完整性、安全性。
  • 避免某一个类被重复加载,确保唯一性。
image-20240417163337417

如何打破双亲委派机制?

  • 自定义类加载器:定义类加载器并且重写loadclass方法,去除双亲委派机制的代码(Tomcat的应用之间类隔离)。

  • 线程上下文类加载器:利用SPI机制和上下文类加载器加载类,比如JDBCJNDIJCEJAXBJBI

    JDBC案例:

    1. 启动类加载器加载DriverManager。
    2. 在初始化DriverManager时,通过SPI机制加载jar包中的myq驱动。
    3. SPI中利用了线程上下文类加载器(应用程序类加载器)去加载类并创建对象。
  • OSGi框架的类加载器:OSGi框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载

方法区

运行时数据区域(JDK1.8)

  • 线程不共享:

    • 程序计数器:记录当前要执行的字节码指令的地址。可以控制程序指令的进行实现分支、跳转、异常等逻辑。

    • 虚拟机栈:采用栈的数据结构来管理方法调用中的基本数据,每一个方法的调用使用一个栈帧来保存。

      栈帧(StackFrame)的组成:

      • 局部变量表:在运行过程中存放所有的局部变量。数据结构是一个数组,数组中每一个位置称之为槽(slot),long和double类型占用两个槽,其他类型占用一个槽。

      • 操作数栈:在执行指令过程中用来存放临时数据的一块区域。

      • 帧数据:主要包含动态链接、方法出口、异常表的引用。

    • 本地方法栈:与上雷同,用来管理native本地方法的栈帧。

  • 线程共享:

    • :用来存放创建出来的对象。栈的局部变量表存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。

    • 方法区:存放类的元信息(基本信息)和运行时常量池,在类的加载阶段完成,方法区是线程共享的。JDK7及之前,方法区存放在堆区域的永久代中,JDK8及之后,方法去存放在直接内存的元空间中。

      • 类的元信息:保存了所有类的基本信息
      • 运行时常量池:保存了字节码文件中的常量池内容
      • 字符串常量池:保存了字符串常量

      字符串常量池和运行时常量池的关系:

      • JDK6及之前:运行时常量池包含字符串常量池,hotspot虚拟机对方法区的实现为永久代。
      • JDK7:字符串常量池被从方法区拿到了堆中,运行时常量池剩下的东西还在永久代。
      • JDK8及之后:hotspot用元空间取代了永久代,字符串常量池还在堆。
    • 直接内存(非运行时数据区的一部分):为了在NIO的使用中,减少对用户的影响,以及提升写文件和读文件的效率,在JDK8及之后,还可以保存方法区中的数据。

运行时数据区域版本变化(JDK 6~8)

JDK版本线程共享的线程不共享的
JDK6程序计数器、Java虚拟机栈、本地方法栈image-20240913203505988
JDK7程序计数器、Java虚拟机栈、本地方法栈image-20240913203533441
JDK8程序计数器、Java虚拟机栈、本地方法栈image-20240913203543608

方法区的垃圾回收

尽管方法区主要用于存储类的元数据,理论上来说这些信息是比较稳定的,但是这并不意味着方法区内不会发生垃圾回收。

实际开发中,类是被应用程序的类加载器创建的,所以开发中方法区的回收一般很少出现,但在如OSGi、JSP的热部署等应用场景中会出现。每个JSP文件对应一个唯一的类加载器,当一个JSP文件被修改了,就直接卸载这个JSP类加载器。重新创建类加载器,重新加载jsp文件。

以下是一些可能导致方法区垃圾回收的情况:

类卸载(Class Unloading)

  • 当一个类不再被引用,并且满足某些条件时,JVM 可能会卸载该类,从而释放方法区内存。这种情况通常发生在使用了类加载器的应用程序中,比如 Web 应用服务器,在应用停止或重新部署时,旧的类加载器和其加载的类可以被卸载。

常量池的清理

  • 方法区内还存放着类的常量池(Constant Pool),如果常量池中的某个常量不再被任何地方引用,那么这个常量就成为了垃圾。例如,当一个字符串常量没有引用时,它可以被回收。

动态代理类的回收

  • 在 Java 中使用动态代理时,会生成一些临时类,如果这些类不再被引用,那么这些类也可以被回收。

编译后的代码缓存

  • 在 JDK 8 及以后的版本中,JIT(Just-In-Time)编译器产生的代码缓存也位于方法区(元空间)。如果这些代码缓存不再有用,也可以被清理。

如何触发方法区的垃圾回收?

方法区的垃圾回收通常不是频繁发生的,因为它主要关注的是类的生命周期。然而,当系统面临内存压力,特别是当方法区内存不足时,JVM 会尝试进行类的卸载。

可以通过以下方式触发方法区的垃圾回收:

  • 调用 System.gc() 或 Runtime.getRuntime().gc():虽然这些方法不保证一定能触发垃圾回收,但在某些情况下,它们可能会导致整个 JVM 进行一次全面的垃圾回收,包括方法区。
  • 使用特定的垃圾收集器:某些垃圾收集器(如 G1)可以在进行常规堆垃圾回收的同时,对方法区进行一定的清理。

含义:类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行。

新生代(Young Generation)

新生代是堆的一部分,主要用于存储新创建的对象。新生代通常被进一步划分为以下几个子区域:

Eden 区

  • 定义:Eden 区是新生代的一部分,新创建的对象首先被放置在这里。
  • 作用:Eden 区是新对象的初始存储区域,当对象创建时,它们首先被放置在 Eden 区。
  • 特性:Eden 区的空间通常较小,因为新创建的对象很快会被垃圾回收。

幸存者区(Survivor Spaces)

  • 定义:幸存者区有两个,分别是 Survivor0 和 Survivor1(通常称为 From 和 To),用于存储经过第一次垃圾回收后仍然存活的对象。
  • 作用:在经过一次 Minor GC(年轻代垃圾回收)之后,Eden 区中的对象如果仍然存活,会被移动到幸存者区之一。
  • 特性:幸存者区的大小通常较小,对象在这里会经历多次 Minor GC,如果仍然存活,则会被移动到老年代。

老年代(Old Generation)

  • 定义:老年代是堆的另一部分,用于存储经过多次垃圾回收仍然存活的对象。
  • 作用:当对象在幸存者区中存活了一定次数(通过年龄计数器 Age Counter),或者对象太大无法放入幸存者区时,会被移动到老年代。
  • 特性:老年代的空间通常较大,因为这里存储的是较为稳定的对象,垃圾回收的频率较低。

永久代(Permanent Generation) [JDK 6 & 7]

  • 定义:永久代(PermGen)用于存储类的元数据、常量池等信息。
  • 作用:在 JDK 6 和 7 中,永久代是用于存储类的元数据的区域。
  • 特性:永久代的垃圾回收主要是针对常量池中的常量。在 JDK 8 中,永久代被移除了,类的元数据被存储在元空间中。

元空间(Metaspace) [JDK 8]

  • 定义:元空间用于存储类的元数据。
  • 作用:在 JDK 8 中,类的元数据从永久代移动到了元空间,元空间使用本地内存(Native Memory)而非 JVM 堆内存。
  • 特性:元空间的大小不受 JVM 堆大小的限制,而是受到系统可用物理内存和系统参数 -XX:MaxMetaspaceSize 的限制。

直接内存(Direct Memory) [Off-Heap Memory]

  • 定义:直接内存不属于堆的一部分,但它仍然与 JVM 相关。
  • 作用:直接内存用于通过 java.nio.ByteBuffer.allocateDirect() 分配的内存,主要用于 NIO(Non-blocking I/O)操作。
  • 特性:直接内存不在 JVM 堆中,因此不受 JVM 垃圾回收的影响。但是,直接内存的大小仍然需要管理,可以通过 -XX:MaxDirectMemorySize 参数来设置。

堆的垃圾回收过程

Minor GC(年轻代垃圾回收)

  • 触发条件:当 Eden 区满时,会触发 Minor GC。
  • 过程:Minor GC 会清理 Eden 区和两个幸存者区中的垃圾对象。存活的对象会被移动到另一个幸存者区或晋升到老年代。
  • 策略:常用的算法有复制算法(Copying),它只需要保留一半的内存即可完成垃圾回收。

Major GC / Full GC(全堆垃圾回收)

  • 触发条件:当老年代空间不足时,会触发 Full GC;或者显式调用 System.gc()Runtime.getRuntime().gc()
  • 过程:Full GC 会清理整个堆,包括年轻代和老年代。
  • 策略:通常使用标记-清除(Mark-Sweep)或标记-清除-压缩(Mark-Sweep-Compact)算法,以避免碎片化的问题。

Parallel GC / Concurrent GC

  • Parallel GC:并行垃圾回收器,它使用多线程来加速垃圾回收过程。适合 CPU 密集型的应用程序。
  • Concurrent GC:并发垃圾回收器,它允许垃圾回收过程与应用程序的执行同时进行,减少应用程序暂停的时间(GC pause time),提高响应速度。适合那些对延迟敏感的应用场景。

堆垃圾回收的优化

为了优化堆的垃圾回收性能,可以调整以下参数:

  • 调整堆大小:通过 -Xms-Xmx 设置初始和最大堆大小。
  • 选择垃圾收集器:根据应用程序的需求选择合适的垃圾收集器,如 CMS Collector、G1 Collector 或 ZGC、Shenandoah 等。
  • 调整年轻代与老年代的比例:通过 -XX:NewRatio 参数调整年轻代与老年代的大小比例。
  • 控制晋升到老年代的对象:通过 -XX:PretenureSizeThreshold 控制对象直接晋升到老年代的大小阈值。
  • 设置幸存者区大小:通过 -XX:SurvivorRatio 设置 Eden 区与幸存者区的比例。

堆分为三部分:

  • Young Generation Space 新生代 Young

    新生区是类的诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。
    新生区又分为两部分: 伊甸区(Eden space)和幸存者区(Survivor pace) ,所有的类都是在伊甸区被new出来的。
    幸存区有两个: 0区(Survivor 0 space)和1区(Survivor 1 space)。
    当伊甸园的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。
    然后将伊甸园中的剩余对象移动到幸存 0区。若幸存 0区也满了,再对该区进行垃圾回收,然后移动到 1 区。
    那如果1 区也满了呢?再移动到养老区。
    若养老区也满了,那么这个时候将产生Major GC(FullGC),进行养老区的内存清理。
    若养老区执行了Full GC之后发现依然无法进行对象的保存,就会产生OOM异常“OutOfMemoryError”。
    如果出现java.lang.OutOfMemoryError: Java heap space异常,说明Java虚拟机的堆内存不够。
    原因有二:
    (1)Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。
    (2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。 ----内存溢出;内存泄漏
  • Tenure generation space 老年代 Old

    老年区用于保存从新生区筛选出来的 JAVA 对象,一般池对象都在这个区域活跃。
  • Permanent Space 永久代 Perm

    永久存储区是一个常驻内存区域,用于存放JDK自身所携带的 Class,Interface 的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭 JVM 才会释放此区域所占用的内存。
    如果出现java.lang.OutOfMemoryError: PermGen space,说明是Java虚拟机对永久代Perm内存设置不够。
    一般出现这种情况,都是程序启动需要加载大量的第三方jar包。
    例如:在一个Tomcat下部署了太多的应用。或者大量动态反射生成的类不断被加载,最终导致Perm区被占满。
    Jdk1.8及之后在元空间

判断一个对象是否存活有两种方法:

  1. 引用计数法

    所谓引用计数法就是给每一个对象设置一个引用计数器,每当有一个地方引用这个对象 时,就将计数器加一,引用失效时,计数器就减一。当一个对象的引用计数器为零时,说明此对象没有被引用,也就是“死对象”,将会被垃圾回收。

    引用计数法有一个缺陷就是无法解决循环引用问题,也就是说当对象 A 引用对象 B,对象 B 又引用者对象 A,那么此时 A,B 对象的引用计数器都不为零,也就造成无法完成垃圾回收,所以主流的虚拟机都没有采用这种算法。

  2. 可达性算法(引用链法)

该算法的思想是:从一个被称为 GC Roots 的对象开始向下搜索,如果一个对象到 GC Roots 没有任何引用链相连时,则说明此对象不可用。

垃圾回收

对象能否被回收,是根据对象是否被引用了来决定的。只有无法通过引用获取到对象时,该对象才能被回收;如果对象被引用了,说明该对象还在使用,不允许被回收。

GC的判断方法

  • 引用计数法

    为每个对象维护一个引用计数器,当对象被引用时加1,取消引用时减1。当对象计数为0时就会触发回收机制。无法解决循环引用的垃圾回收。

    引用类型

    • 强引用:如果对象在根对象的引用链上,则不能被回收。

    • 软引用:如果对象被软引用关联,当程序内存不足时会回收。

      在JDK1.2版之后提供了SoftReference类来实现软引用,软引用常用于缓存中。

    • 弱引用:和软引用基本一致,区别在于弱引用在垃圾回收时,会被直接回收。

      在JDK1.2版之后提供了WeakReference类来实现弱引用,弱引用主要在ThreadLocal中使用。弱引|用对象本身也可以使用引|用队列进行回收。

    • *虚引用:无法获取包含的对象。唯一用途是当对象被回收时,可以接收到对应的通知,知道对象被回收了。

      Java中使用PhantomReference实现了虚引用,使用虚引用实现了直接内存中为了及时知道直接内存对象不再使用,从而回收内存。

    • 终结器引用:对象回收时可以自救,不建议使用。(在对象需要被回收时,对象将会被放置在Finalizer类中的引用队列中,并在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法(再将自身对象使用强引用关联上))

  • 可达性分析法

    如果从某个到GC Root对象是可达的,对象就不可被回收。

    根对象(不可回收):

    • 线程Thread对象
    • 系统类加载器加载的java.lang.Class对象
    • 监视器对象,用来保存同步锁synchronized关键字持有的对象
    • 本地方法调用时使用的全局对象

触发GC的时机

  1. 新生代空间不足:当新生代空间不足时,会触发Minor GC。如果Minor GC之后仍然空间不足,则触发Full GC
  2. 老年代空间不足:当老年代空间不足时,会先尝试触发Minor GC,如果之后空间仍不足,则会触发Full GC
  3. 元空间不足:元空间存放类的元数据,当元空间不足时也会触发Full GC
  4. 显式调用 System.gc() :虽然 System.gc() 方法的调用是一个建议,但很多情况下JVM会响应这个请求并触发Full GC
  5. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存:如果发现这个平均值大于老年代的可用内存,则会在Minor GC前先进行一次Full GC
  6. 定时触发:某些情况下,GC可能会基于时间间隔或其他策略被触发。

GC算法

垃圾回收算法评价标准

  1. 吞吐量:指的是CPU用于执行用户代码的时间与CPU总执行时间的比值,即吞吐量=执行用户代码时间 /(执行用户代码时间+GC时间)。吞吐量数值越高,垃圾回收的效率就越高。
  2. 最大暂停时间:指的是所有在垃圾回收过程中的STW时间最大值。
  3. 堆使用效率:不同垃圾回收算法,对堆内存的使用方式是不同的。比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法。
  • 标记-清除算法:先标记,标记完毕之后再清除,效率不高,会产生碎片

    1. 标记阶段,使用可达性分析算法将所有存活的对象进行标记,从GCRoot开始通过引用链遍历出所有存活对象。
    2. 清除阶段,从内存中删除没有被标记的非存活对象。

    优点:实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。

    缺点:

    1. 碎片化问题由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。
    2. 分配速度慢。由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。
  • 标记-整理(标记-压缩)算法:标记完毕之后,让所有存活的对象向一端移动

    是对标记-清理算法中容易产生内存碎片问题的一种解决方案。

    1. 标记阶段,将所有存活的对象进行标记。使用可达性分析算法,从GCRoot开始通过引l用链遍历出所有存活对象。
    2. 整理阶段,将存活对象移动到堆的一端。清理掉存活对象的内存空间。

    优点:

    1. 内存使用效率高。整个堆内存都可以使用,不会像复制算法只能使用半个堆内存。
    2. 不会发生碎片化。在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间。

    缺点:整理阶段的效率不高。整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体性能不佳。可以通过Two-Finger表格算法ImmixGc等高效的整理算法优化此阶段的性能。

  • 复制算法:分为 8:1 的 Eden 区和 survivor 区,就是上面谈到的 YGC

    1. 将堆内存分割成两块From空间To空间,对象分配阶段,创建对象。
    2. GC阶段开始,将GC Root搬运到To空间。
    3. 将GCRoot关联的对象,搬运到To空间。
    4. 清理From空间,并把名称互换。

    优点:

    1. 吞吐量高。复制算法只需要遍历一次存活对象复制到To空间即可,比标记-整理算法少了一次遍历的过程,因而性能较好,但是不如标记-清除算法因为标记清除算法不需要进行对象的移动。
    2. 不会发生碎片化。复制算法在复制之后就会将对象按顺序放入To空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。

    缺点:内存使用效率低。每次只能让一半的内存空间来为创建对象使用。

  • 分代垃圾回收(分代GC)

    1. 将整个内存区域划分为年轻代老年代
    2. 分代回收时,创建出来的对象,首先会被放入Eden伊甸园区。
    3. 随着对象在Eden区越来越多,Eden区满了就会触发年轻代的GC(Minor GC / Young GC),将不需要回收的对象放到To区,新创建的对象继续放到Eden区。
    4. 如果MinorGC后对象的年龄达到阈值(最大15,最小为0,默认值和垃圾回收器有关),对象就会被晋升至老年代。
    5. 当老年代中空间不足,无法放入新的对象时,先尝试Minor GC如果还是不足,就会触发Full GC,Full GC会对整个堆进行垃圾回收。
    6. 如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常。

垃圾回收器

垃圾回收器的发展

  • Serial 收集器:最早期的垃圾收集器,适用于小型应用或测试环境,几十兆的内存大小。

  • Parallel 收集器:随着硬件进步,内存容量增加,适用于几个G的内存大小。

  • CMS 收集器:在JDK 1.4版本后期引入,开启了并发垃圾回收的时代,适用于几十个G的内存大小,但由于其并发标记阶段会影响应用程序性能,并且存在碎片化问题。

  • G1 收集器:设计用于上百GB的内存大小,通过并行和并发的方式进行垃圾回收,减少了Stop-The-World(STW)时间。

  • ZGC 和 Shenandoah:设计用于更大的内存范围,从几百GB到TB级别的内存,减少了STW时间到毫秒级别。

垃圾回收器介绍

  1. Serial 收集器
    • 适用于年轻代的串行回收,适合内存较小的应用环境。
  2. Parallel Scavenge 收集器 (PS)
    • 适用于年轻代,并行回收,适用于内存较大的应用环境。
  3. ParNew 收集器
    • 是 Serial 收集器的一个变种,支持年轻代的并行回收,主要用来配合 CMS 收集器使用。
  4. Serial Old 收集器
    • 适用于老年代的串行回收,通常作为 CMS 收集器在无法分配新对象时的后备方案。
  5. Parallel Old 收集器
    • 适用于老年代,并行回收。
  6. CMS 收集器 (Concurrent Mark Sweep)
    • 适用于老年代,采用并发标记和清扫的方式,减少 STW 时间,但由于标记算法的原因,容易产生内存碎片。
  7. G1 收集器
    • 适用于大内存环境,采用三色标记算法和SATB(Store After Barrier)机制,减少了 STW 时间。
  8. ZGC 收集器
    • 设计用于非常大的内存范围,采用 Colored Pointers 和 Load Barrier 技术,进一步减少 STW 时间。
  9. Shenandoah 收集器
    • 也是为了解决 STW 问题,采用 Colored Pointers 和 Write Barrier 技术。
  10. Epsilon 收集器
    • 这是一个“无操作”的垃圾收集器,仅用于研究目的或特殊场合,不执行任何垃圾回收工作。

关键算法和技术

  • 三色标记算法:一种并发标记算法,用于并发垃圾回收过程中标识对象的状态。

    1. 白色:表示尚未被访问的对象。这些对象可能是垃圾,也可能不是。
    2. 灰色:表示已经被访问但其引用的对象尚未被访问的对象。这些对象是当前正在处理的对象。
    3. 黑色:表示已经被完全访问的对象。这些对象及其引用的对象都被认为是存活的。

    算法步骤

    1. 初始化
      • 将所有对象标记为白色。
      • 将根对象(如全局变量、栈上的局部变量等)标记为灰色。
    2. 标记阶段
      • 从灰色对象队列中取出一个对象,将其标记为黑色。
      • 将该对象引用的所有白色对象标记为灰色,并将这些对象加入灰色对象队列。
      • 重复上述过程,直到灰色对象队列为空。
    3. 清理阶段
      • 所有仍标记为白色的对象被认为是垃圾,可以被回收。
  • Incremental Update:一种在并发标记过程中更新对象引用的技术。

  • SATB (Store After Barrier):一种在对象更新时记录写操作的技术。

  • Colored Pointers:一种指针技术,使得对象可以直接携带有关其状态的信息,简化了并发回收过程中的对象检查。

  • Load BarrierWrite Barrier:在读取或写入对象时插入的屏障,确保垃圾回收的安全性。

JVM中的垃圾回收器

垃圾回收器是垃圾回收算法的具体实现,在JVM中,实现了多种垃圾收集器,包括:

  • 串行垃圾收集器
    • Serial和Serial Old串行垃圾收集器,是指使用单线程进行垃圾回收,堆内存较小,适合个人电脑
    • Serial 作用于新生代,采用复制算法
    • Serial Old 作用于老年代,采用标记-整理算法
    • 垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成。
  • 并行垃圾收集器
    • Parallel New和Parallel Old是一个并行垃圾回收器,JDK8默认使用此垃圾回收器
    • Parallel New作用于新生代,采用复制算法
    • Parallel Old作用于老年代,采用标记-整理算法
    • 垃圾回收时,多个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成。
  • CMS(并发)垃圾收集器
    • CMS全称 Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。其最大特点是在进行垃圾回收时,应用仍然能正常运行。
  • G1垃圾收集器
    • 应用于新生代和老年代,在JDK9之后默认使用G1
    • 划分成多个区域,每个区域都可以充当 eden,survivor,old, humongous,其中 humongous 专为大对象准备
    • 采用复制算法
    • 响应时间与吞吐量兼顾
    • 分成三个阶段:新生代回收、并发标记、混合收集
    • 如果并发失败(即回收速度赶不上创建新对象速度),会触发 Full GC

由于垃圾回收器分为年轻代和老年代,除了G1之外其他垃圾回收器必须成对组合进行使用,具体的关系图如下:

image-20240427133704772
  1. 年轻代-Serial垃圾回收器 + 老年代-SerialOld垃圾回收器
image-20240427142529922
回收年代算法优点缺点适用场景
年轻代复制算法单CPU处理器下吞吐量非常出色多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待Java编写的客户端程序或者硬件配置有限的场景
image-20240427142529922
回收年代算法优点缺点适用场景
老年代标记-整理算法单CPU处理器下吞吐量非常出色多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待与Serial垃圾回收器搭配使用,或者在CMS特殊情况下使用
  1. 年轻代-ParNew垃圾回收器 + 老年代- CMS(Concurrent Mark Sweep)垃圾回收器

    image-20240427142921039
回收年代算法优点缺点适用场景
年轻代复制算法多CPU处理器下停顿时间较短吞吐量和停顿时间不如G1,所以在JDK9之后不建议使用JDK8及之前的版本中,与CMS老年代垃圾回收器搭配使用
image-20240427143027649
回收年代算法优点缺点适用场景
老年代标记-清除算法系统由于垃圾回收出现的停顿时间较短,用户体验好内存碎片问题、退化问题、浮动垃圾问题大型的互联网系统中用户请求数据量大、频率高的场景,比如订单接口、商品接口等

CMS执行步骤:

1.初始标记,用极短的时间标记出GC Roots能直接关联到的对象。

2.并发标记, 标记所有的对象,用户线程不需要暂停。

3.重新标记,由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记。

4.并发清理,清理死亡的对象,用户线程不需要暂停。

缺点:

1、CMS使用了标记-清除算法,在垃圾收集结束之后会出现大量的内存碎片,CMS会在Full GC时进行碎片的整理。这样会导致用户线程暂停,可以使用-XX:CMSFullGCsBeforeCompaction=N 参数(默认0)调整N次Full GC之后再整理。

2.、无法处理在并发清理过程中产生的“浮动垃圾”,不能做到完全的垃圾回收。

3、如果老年代内存不足无法分配对象,CMS就会退化成Serial Old单线程回收老年代。

  1. 年轻代-Parallel Scavenge垃圾回收器 + 老年代-Parallel Old垃圾回收器(JDK 1.8默认的垃圾回收器)
image-20240427143236251
回收年代算法优点缺点适用场景
年轻代复制算法吞吐量高,而且手动可控。为了提高吞吐量,虚拟机会动态调整堆的参数不能保证单次的停顿时间后台任务,不需要与用户交互,并且容易产生大量的对象。比如:大数据的处理,大文件导出
image-20240427143236251
回收年代算法优点缺点适用场景
老年代标记-整理算法并发收集,在多核CPU下效率较高暂停时间会比较长与Parallel Scavenge配套使用
  1. G1垃圾回收器(JDK9之后强烈建议)
  • 支持巨大的堆空间回收,并有较高的吞吐量。
  • 支持多CPU并行垃圾回收。
  • 允许用户设置最大暂停时间。

G1的整个堆会被划分成多个大小相等的区域,称之为区Region,区域不要求是连续的。分为Eden、Survivor、Old区。Region的大小通过堆空间大小/2048计算得到,也可以通过参数-XX:G1HeapRegionSize=32m指定(其中32m指定region大小为32M),Region size必须是2的指数幂,取值范围从1M到32M。

image-20240427143448227

回收过程:(简略版)

  1. 当G1判断年轻代区不足(max默认60%),无法分配对象时需要回收时会执行Young GC。

    根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区中(年龄+1),清空这些区域。

image-20240427143816094
  1. 当某个存活对象的年龄到达阈值(默认15),将被放入老年代。
image-20240427143922933
  1. 部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为Humongous区。比如堆内存是4G,每个Region是2M,只要一个大对象超过了1M就被放入Humongous区,如果对象过大会横跨多个Region。
image-20240427143951445
  1. 多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值45%(默认)时,会触发混合回收MixedGC(过程略)。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来完成。

JVM场景题

内存泄漏是指:不再使用的对象仍然占用内存空间,因为垃圾回收器无法回收它们。这种情况下,应用程序会逐渐消耗越来越多的内存,最终可能导致性能下降甚至崩溃。以下是一些常见的导致 Java 应用程序内存泄漏的场景:

1. 静态集合类

当集合类被声明为静态变量时,它们的生命周期与整个应用程序相同,如果不定期清理,会导致内存持续增长。

2. 内部类和 Lambda 表达式

内部类(Inner Class)和 Lambda 表达式可能会持有对外部类的隐式引用,从而阻止垃圾回收。

3. 日志记录

日志记录类可能会持有某个对象的强引用,特别是当使用 MDC(Mapped Diagnostic Context)时,如果不及时清除 MDC 中的信息,可能会导致内存泄漏。

4. 线程局部变量(ThreadLocal)

ThreadLocal 变量如果没有正确地清除,可能导致内存泄漏,因为每个线程都会保留一份拷贝。